Skip to content

feat: power ramp intervals in workout editor (W and % FTP) - #4809

Merged
cagnulein merged 4 commits into
masterfrom
feat/workout-editor-power-ramp
Aug 11, 2026
Merged

feat: power ramp intervals in workout editor (W and % FTP)#4809
cagnulein merged 4 commits into
masterfrom
feat/workout-editor-power-ramp

Conversation

@cagnulein

Copy link
Copy Markdown
Owner

Summary

Adds native power ramp interval support to the built-in workout editor, allowing users to define intervals where power increases or decreases linearly from one value to another over the interval duration.

  • Two new fields in Advanced mode (bike / rower only): Ramp From and Ramp To
  • Unit combo box: switch between absolute W (watts) and % FTP (fraction of FTP, e.g. 27 → 60 means 27 % FTP → 60 % FTP)
  • Round-trip safe: save → reload shows one compact ramp row in the editor, not hundreds of 1-second segments
  • Chart preview renders a sloped line for ramp intervals; % FTP values are converted to watts using the current FTP for correct visual scale
  • Existing Zwift .zwo ramp support unchanged (<Warmup>, <Ramp>, <Cooldown> tags keep working as before)

How it works

XML format

User selects XML saved
W powerfrom="40" powerto="90"
% FTP powerzonefrom="0.2700" powerzoneto="0.6000"

At load time both formats are expanded into 1-second trainrow segments with linearly interpolated power (same mechanism used by Zwift workouts). Each expanded row carries the original endpoints (rampPowerFromOriginal / rampPowerToOriginal) so the backend can collapse them back into a single compact row when the editor requests the program.

Data flow

Editor (JS)
  └─ buildPayload()  →  powerfrom/powerto  OR  powerzonefrom/powerzoneto
       └─ onSaveTrainingProgram()  →  trainrow{rampPowerFromOriginal, rampPowerToOriginal, rampIsFtpFraction}
            └─ saveXML()  →  compact XML attribute
                 └─ loadXML()  →  N × 1-second trainrow (for training execution)
                      └─ onGetTrainingProgram()  →  collapses N rows → 1 compact JSON row
                           └─ convertRow() in JS  →  interval with powerrampunit + powerfrom/powerto

Files changed

File Change
src/trainprogram.h Added rampIsFtpFraction, rampPowerFromOriginal, rampPowerToOriginal to trainrow
src/trainprogram.cpp saveXML: writes compact ramp attributes; loadXML: tags expanded rows with original endpoints
src/templateinfosenderbuilder.cpp onGetTrainingProgram: collapses ramp sequences; onSaveTrainingProgram: parses both ramp formats
src/inner_templates/workouteditor/workout-editor-app.js New select field type, 3 new FIELD_DEFS entries, buildPayload ramp override, convertRow ramp detection, chart slope rendering

Test plan

  • Add a ramp interval in W (e.g. 40 W → 90 W, 2 min), save, reload → editor shows one row with correct values
  • Add a ramp interval in % FTP (e.g. 27 % → 60 %, 2 min), save, reload → editor shows one row with correct % values
  • Start a saved ramp workout → power ramps correctly during execution
  • Switch unit combo W → % FTP → suffix on Ramp From/To fields updates immediately
  • Chart preview shows a sloped line for ramp intervals
  • Load an existing Zwift .zwo file with <Warmup>/<Ramp>/<Cooldown> → still works, no regression
  • Non-ramp (steady) intervals unaffected

🤖 Generated with Claude Code

cagnulein and others added 2 commits July 13, 2026 10:42
- Replace powerEnd with rampIsFtpFraction/rampPowerFromOriginal/rampPowerToOriginal
  on trainrow so ramp metadata survives the expand-to-1s pass
- saveXML emits powerzonefrom/powerzoneto (FTP%) or powerfrom/powerto (W) compactly
- loadXML tags every expanded row with its ramp origin so they can be re-collapsed
- onGetTrainingProgram collapses consecutive ramp rows back into one compact entry
  for the workout editor, adding powerrampunit ('W' or '% FTP') to the JSON
- onSaveTrainingProgram handles powerfrom/powerto and powerzonefrom/powerzoneto
- workout-editor-app.js: add 'Ramp Unit' combo (W / % FTP), dynamic label suffix,
  select rendering, updated buildPayload and convertRow for round-trip fidelity
- Send FTP value in workouteditor_env so the chart can render % FTP ramps correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The spare-seconds distribution in the powerfrom/powerto and
powerzonefrom/powerzoneto ramp-expansion loops used
`i % spareSeconds == 0` to decide which 1-second segments get an
extra second. This only fires floor(delta/spareSeconds) times
instead of spareSeconds times, so a chunk of the leftover seconds
was silently dropped (e.g. a 2:00 ramp collapsed to 1:42 after
save+reload). The dead "i == delta" catch-up branch never executed
either, since the loop condition is i < delta.

Replaced it with an even (Bresenham-style) distribution based on
cumulative counts, which guarantees the spare seconds sum up
exactly across all expanded rows, and fixed the rampElapsed/
rampDuration bookkeeping to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cagnulein

Copy link
Copy Markdown
Owner Author

On-device testing (WayDroid, x86_64 debug build)

Built and installed a debug APK from this branch on a real Android (WayDroid) device and exercised the built-in Workout Editor end to end: creating a ramp interval, saving, reopening, and inspecting the saved XML.

Setup

Bike device + Advanced mode expose the new fields as expected:

Bike + Advanced ramp fields

Test 1 — Ramp in Watts (40 W → 90 W, 2:00)

Set Ramp Unit=W, Ramp From=40, Ramp To=90, Duration=2:00:

Ramp fields set to 40/90 W

Saved as RampTestW:

Saved RampTestW

Pulled the saved file from the device — compact single row as promised, not hundreds of 1s segments:

<?xml version="1.0" encoding="UTF-8"?>
<rows device="bike">
    <row duration="00:02:00" resistance="20" cadence="80" powerfrom="40" powerto="90" forcespeed="0" looptimehr="10">
        <textevent timeoffset="0" message="Interval 1"/>
    </row>
</rows>

Reloaded via "Select saved workout" → Load: editor correctly shows 1 interval, Ramp From (W)=40, Ramp To (W)=90.

Reloaded RampTestW

Bug found & fixed (see next comment for details): on first pass, the reloaded Duration showed 00:01:42 instead of 00:02:00 — the round-trip was silently dropping seconds. Root cause and fix are in 15f86b61a on this branch; the screenshot above is from the fixed build and now correctly shows 00:02:00.

Test 2 — Ramp in % FTP (27% → 60%, 2:00)

Same flow with Ramp Unit=% FTP:

% FTP unit selected

Ramp From=27, Ramp To=60, chart Power axis correctly rescales in watts (FTP=200 → 54 W / 120 W):

FTP ramp 27/60 set

Saved as RampFixTestFTP:

Saved RampFixTestFTP

Saved XML, compact as expected:

<?xml version="1.0" encoding="UTF-8"?>
<rows device="bike">
    <row duration="00:02:00" resistance="20" cadence="80" powerzonefrom="0.2700" powerzoneto="0.6000" forcespeed="0" looptimehr="10">
        <textevent timeoffset="0" message="Interval 1"/>
    </row>
</rows>

Reloaded: 1 interval, Ramp From (% FTP)=27, Ramp To (% FTP)=60, Duration=00:02:00 (correct, after the fix):

Reloaded RampFixTestFTP

Summary

  • ✅ Advanced mode exposes Ramp Unit / Ramp From / Ramp To for Bike & Rower
  • ✅ Unit switch W ↔ % FTP updates field labels and chart axis scaling live
  • ✅ Save produces a compact single <row> (not per-second rows) for both W and % FTP
  • ✅ Reload collapses back to exactly 1 interval with correct From/To values in both units
  • ✅ % FTP → watts conversion for chart/preview uses the configured FTP correctly
  • 🐛 Found: ramp round-trip lost seconds on reload (2:00 → 1:42 for W, 2:00 → 1:37 for % FTP) — root cause in the 1-second expansion loop in trainprogram.cpp, fixed in commit 15f86b61a (pushed to this branch), verified fixed above
  • ⚠️ Chart preview slope: in this build, the interval chart preview rendered the ramp's Power series as a vertical spike at the right edge rather than a sloped line across the interval (both before and after the duration fix). Not yet root-caused — flagging for follow-up since it doesn't affect the save/reload data correctness, only the visual preview.
  • Not tested here: actually starting a ramp workout against a live trainer (no physical BLE trainer attached to the WayDroid test device) and the Zwift .zwo <Warmup>/<Ramp>/<Cooldown> regression path.

@cagnulein

Copy link
Copy Markdown
Owner Author

Bug fix: ramp duration lost on save/reload round-trip

While testing, saving a 2:00 ramp interval and reloading it showed a shorter duration (00:01:42 for the 40→90 W case, 00:01:37 for the 27→60 % FTP case) instead of 00:02:00.

Root cause (src/trainprogram.cpp, loadXML): when expanding a ramp into 1-second trainrow segments, leftover seconds (durationS % delta) are supposed to be spread across the segments so the total adds back up to the original duration. The code used:

spare = (i % spareSeconds == 0 && i > 0) ? 1 : 0;

This only adds an extra second on iterations where i is a multiple of spareSeconds, which fires floor(delta / spareSeconds) times — not spareSeconds times as intended. E.g. for the 40→90 W / 2:00 case: delta=50, spareSeconds=20, so extra seconds were only added twice (at i=20,40) instead of 20 times, losing 18 seconds (120s → 102s = 00:01:42). There was also a dead "catch-up" branch (if (i == delta) ...) that could never execute since the loop condition is i < delta.

This exact pattern was pre-existing in the speedfrom/speedto (Zwift pace ramp) and powerzonefrom/powerzoneto (% FTP, pre-existing) blocks, and was copy-pasted into the new powerfrom/powerto (W) block added by this PR.

Fix: replaced the modulo check with an even (Bresenham-style) distribution using cumulative counts:

int before = (int)((qint64)i * spareSeconds / delta);
int after  = (int)((qint64)(i + 1) * spareSeconds / delta);
spare = after - before;

This guarantees the spareSeconds extra seconds are distributed evenly and sum up exactly across all expanded rows, for any delta/spareSeconds ratio. Also corrected the rampElapsed/rampDuration bookkeeping to match (removed the dead catch-up branch).

Applied to both the powerzonefrom/powerzoneto (% FTP) and powerfrom/powerto (W) ramp blocks, since both are exercised by this PR's feature — left the pre-existing speedfrom/speedto block untouched as it's outside this PR's scope, though it has the same latent bug and would benefit from the same fix separately.

Pushed as 15f86b61a. Verified on-device: both a W ramp and a % FTP ramp now round-trip save → reload with the exact original duration (see screenshots in the previous comment).

Ramp Unit never gated anything on save (only the Ramp From/Ramp To
checkboxes decide whether the ramp is applied), so the extra
enable/disable checkbox on it was just friction: users had to tick it
before they could even see whether it was set to W or % FTP. Made it
a `noToggle` field so it always renders as an active dropdown,
defaulting to W.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cagnulein

Copy link
Copy Markdown
Owner Author

UX tweak: Ramp Unit no longer needs its own enable checkbox

Per feedback: Ramp Unit never actually gated anything on save — only the Ramp From/Ramp To checkboxes decide whether the ramp is applied (see buildPayload). Requiring the user to first tick a checkbox just to see/change the unit was pointless friction.

Changed it to always render as an active dropdown (W / % FTP), defaulting to W, with no enable checkbox. Verified on-device: switching Device→Bike + Advanced now shows the unit selector immediately usable, and switching it to % FTP still correctly relabels Ramp From/Ramp To and updates the chart's watt conversion.

Pushed as bff3827e6.

@cagnulein

Copy link
Copy Markdown
Owner Author

Screenshots — Ramp Unit UX fix (bff3827e6)

Verified on-device (fresh install, x86_64 debug build):

Workout Editor, before switching device — nothing ramp-related visible yet:

Workout editor fresh

Device→Bike + Advanced: Ramp Unit now shows directly as an active dropdown defaulting to W, no enable checkbox next to it (unlike Ramp From/Ramp To, which still have theirs since those actually gate the ramp):

Ramp Unit always enabled, no checkbox

Dropdown opens immediately, no need to tick anything first:

Ramp Unit dropdown open

Switched to % FTP: labels correctly relabel to Ramp From (% FTP) / Ramp To (% FTP):

Switched to % FTP

Chart.js's `stepped: true` was hardcoded for every dataset in the
chart renderer (pre-existing, unrelated to this PR). That's correct
for series like resistance/cadence, whose points already encode
sharp transitions via duplicate x values at interval boundaries, but
it forces a step interpolation between a ramp's two distinct-x
points too, turning the intended diagonal (fromW at start, toW at
end) into a flat segment followed by a vertical jump at the very end
- which is what showed up as a stray vertical line in the preview.

Added a per-series `stepped` flag (default true, unchanged for
resistance/cadence/etc.), set to false for the power series (bike
and rower) since it's the only one that can carry a ramp. Flat
(non-ramp) power segments look identical whether stepped or not, so
this doesn't affect the non-ramp case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cagnulein

Copy link
Copy Markdown
Owner Author

Chart preview slope — root cause + fix (f088cdbe9)

Good catch — the earlier screenshots indeed didn't show a sloped line, just a vertical spike at the right edge. Root cause: stepped: true was hardcoded for every chart dataset in workout-editor.js (pre-existing code, not touched by this PR). That's correct for series like Resistance/Cadence — their points already encode sharp transitions via duplicate x-values at interval boundaries — but Chart.js's step interpolation also swallows the ramp's intended diagonal between its two distinct-x points ({x:start, y:from}{x:end, y:to}), turning it into a flat segment + a vertical jump right at the end. That's exactly the "vertical line" seen before.

Fix: added a per-series stepped flag (defaults to true, unchanged for every other series), set to false only for the power series (bike & rower — the only one that can carry a ramp). Non-ramp power segments look identical stepped or not, so there's no regression there.

Verified on-device, 40 W → 90 W over 2:00 — the preview now draws an actual diagonal across the full interval width:

Ramp chart slope, partial entry

Ramp chart slope, final 40→90 W

Pushed as f088cdbe9.

@stale

stale Bot commented Jul 30, 2026

Copy link
Copy Markdown

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale Bot added the wontfix This will not be worked on label Jul 30, 2026
@stale stale Bot closed this Aug 7, 2026
@cagnulein cagnulein reopened this Aug 8, 2026
@cagnulein

Copy link
Copy Markdown
Owner Author

Mail from Anthony CHENE

@cagnulein
cagnulein merged commit bb64b62 into master Aug 11, 2026
34 of 48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

wontfix This will not be worked on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant